15 编写大规模程序

本章源码:https://github.com/laputa-er/c_demos/tree/master/c_programming_a_modern_approach

15.1 源文件

说明:可以把程序分割成一定数量的源文件(.c文件和.h文件)。

  • 源文件的扩展名为.c,每个源文件包含程序的部分内容,主要是函数的定义和变量
  • 一个源文件必须包含名为main的函数,此函数作为程序的起始点

优点:把程序分裂成多个源文件有许多显著的优点。

  • (易读)把相关的函数和变量集合在单独一个文件中可以帮助明了程序的结构。
  • (易维护)可以单独对每一个文件进行编译。
  • (易复用)当把函数集合在单独的源文件中时,会更容易在其他程序中重新使用这些函数。

案例-计算器

逆波兰符号(Reverse Polish Notation, RPN):指运算符都跟在操作数的后边。例如:30 5 - 7 *
思路:程序逐个读入操作数和运算符,那么利用栈跟踪中间结果这样的方式计算逆波兰表达式是很容易的。

  1. 读取“记号”(数或运算符)
  2. 如果程序读取数,就将此数压入栈
  3. 如果程序读取运算符,那么将从栈顶弹出两个数进行相应的计算。

15.2 头文件

说明:如果打算几个源文件可以访问相同的信息,那么将把此信息放在文件中,扩展名为.h,然后利用#include指令把文件的内容带进每个源文件中。这样的.h文件就是头文件(或包含文件)。

15.2.1 #include指令

语法:有两种格式,其中的文件名可以包含路径或驱动器号。而且预处理器不会讲"文件名"当作字符串处理了,不然DOS路径中的某些字符有可能被当作转义字符。

格式 搜索目标 备注
#include <文件名> 系统头文件所在的目录 有可能是多个,通常是/usr/include
#include “文件名” 搜索当前目录,然后搜索系统头文件所在的目录 可以通过诸如-I选项修改搜索目录
1
2
#include "c:\cprogs\utils.h"/*DOS path*/
#include "/cprogs/utils.h"/*UNIX path*/

可移植性技巧:不要在#include指令中包含路径或驱动器信息。

1
2
3
#include <sys\stat.h>
#include <utils.h>
#include <..\include\utils.h>

15.2.2 共享宏定义和类型定义

说明:将通用的宏定义和类型定义放在头文件中有许多明显的好处。

  • 不用频繁复制代码
  • 易于修改和维护
  • 避免由于源文件包含相同宏或类型的不同定义而导致的矛盾

boolean.h

1
2
3
#define TRUE 1
#define FALSE 0
typedef int Bool;

15.2.3 共享函数原型

说明:为了共享函数,要把函数的定义放在一个源文件中,然后在需要调用此函数的其他文件中放置声明。
技巧:为了保证函数原型声明一致,声明部分单独放在一个头文件中。然后在定义和调用的源文件中都引入该头文件。
注意:调用在其他文件中的函数时,要始终确保编译器在调用之前看到函数的原型。

案例-计算器

1. 头文件:stack.h
说明:包含共享的函数的原型声明。
注意:只在calc.c中使用的的函数不应该定义在该头文件中。

1
2
3
4
5
void make_empty(void);
int is_empty(void);
int is_full(void);
void push(int i);
int pop(void);

2. 函数定义:stack.c
说明:实现stack.c中声明的所有函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <stdio.h>
#include "stack.h"

int contents[100];
int top = 0;

void make_empty(void){
printf("%s\n", "what");
}
int is_empty(void){
printf("%s\n", "what");
}
int is_full(void){
printf("%s\n", "what");
}
void push(int i){
printf("%s\n", "what");
}
int pop(void){
printf("%s\n", "what");
}

3. 入口文件:calc.c

1
2
3
4
5
6
7
#include <stdio.h>

#include "stack.h"
int main(){
//编译器会通过stack.h中make_empty的原型找到对应的定义,从而正确调用
make_empty();
}

编译运行

1
2
3
$ gcc -o calc calc.c stack.c
$ ./calc
what

15.2.4 共享变量声明

说明:为了共享变量i,首先把变量i的定义(和初始化)放置在一个文件中,而在其他文件中包含变量i的声明(使用关键字extern)。
变量声明:extern

  • 类似函数的声明,仅声明变量名和类型(内存不会为其分配空间)
  • 通常情况下我们不使用extern,这种情况下变量声明和定义同时完成
  • 可以用于所有类型的变量
  • 在数组的声明中使用extern时可以忽略数组的长度extern int a[];
  • 编译器无法检查变量声明是否和变量定义严格匹配,因此有可能出现和声明类型不一致的定义,这会导致程序的异常行为

技巧:通常把共享的变量的声明放置在头文件中,需要访问该共享变量的源文件中引入该头文件。同时如果变量的定义在其它源文件(而不是入口文件中),则也需要引入该头文件。
扩展:虽然在文件中共享变量是c语言界的长期惯例,但是它有重大缺陷。19.2节有如何设计不需要共享变量的程序的知识。

1
2
//extern提示编译器变量i是在程序的其它位置定义的(同一文件或不同文件)
extern int i;

15.2.5 嵌套包含

说明:头文件自身可以包含#include指令。

1
2
3
4
#include "boolean.h"

Bool is_empty(void);
Bool is_full(void);

15.2.6 保护头文件

为什么保护:如果源文件包含同一个文件两次(直接或间接),那么可能(如果包含类型定义)会产生编译错误。

  • 避免由重复的类型定义导致的编译错误
  • 节约编译时间

如何保护:为了防止头文件多次包含导致的多次编译,将用#ifndef#endif两个指令把文件闭合起来。在预编译阶段去重复掉引入的头文件的代码。

1
2
3
4
5
6
7
//BOOLEAN_H是按照所在头文件名(BOOLEAN.h)进行命名的,目的是避免和其它头文件中的宏冲突
#ifndef BOOLEAN_H
#define BOOLEAN_H
#define TRUE 1
#define FALSE 0
typedef int Bool;
#endif

15.2.7 头文件中的#error指令

用途:放在头文件中用来检查不应该包含头文件的条件。

1
2
3
4
5
//只有在DOS程序中才能正常使用
#ifndef DOS
//如果非DOS程序试图包含此头文件,那么编译将在#error指令处停止
#error Grapphics supported only under DOS
#endif

15.3 把程序划分成多个文件(程序:文本格式化)

功能分析:能够将输入的文本格式化的命令行工具。

  • “删除空行、制表符”
  • “填充”:添加单词直到再多一个单词就会导致溢出时才停止
  • “调整”:除最后一行外,在单词间添加额外的空格以便每行有精确的相同长度(60个字符)

功能演示
原始文本放在 test.txt 文件中,

1
2
3
4
5
6
to test whether the program fmt		work well.       
this test

writen in
2016, changed in 2017- 2-5 22:00
hope

使用该程序整理 test.txt 的格式,并将整理好的文本输出,如下

1
2
3
$ ./fmt < test.txt
to test whether the program fmt work well. this test writen
in 2016, changed in 2017- 2-5 22:00 hope

15.4 构建多文件程序

原理:大多数编译器允许一步完成编译和链接的过程。

  1. 编译:对每个源文件(不包括头文件)分别进行编译
  2. 链接:把上一步产生的目标文件和库函数的代码结合在一起生成可执行的程序。
1
2
#-o告诉编译器最终的可执行文件的名字
$ gcc -o fmt fmt.c line.c word.c

15.4.1 makefile

命令行编译的缺点:

  • 枯燥乏味(敲没有营养的编译命令)
  • 浪费时间,所有源文件每次都会被重新编译
  • 构建大规模程序费时费力易出错

说明:Unix系统发明了makefile的概念,这个文件包含构建程序的必要信息。

  1. 列出了作为程序部分的文件
  2. 描述了文件之间的依赖性

基本语法:

1
2
目标文件名:依赖的文件
[tab]命令

扩展:不是每个人都使用makefile,其它程序维护工具正变得流行,包括一些集成开发环境支持的“工程文件”。

UNIX

1
2
3
4
5
6
7
8
fmt:fmt.o word.o line.o
gcc -o fmt fmt.o word.o line.o
fmt.o:fmt.c word.h line.h
gcc -c fmt.c
word.o:word.c word.h
gcc -c word.c
line.o:line.c line.h
gcc -c line.c

WIN

1
2
3
4
5
6
7
8
fmt.exe:fmt.obj word.obj line.obj
gcc -o fmt fmt.obj word.obj line.obj
fmt.obj:fmt.c word.h line.h
gcc -c fmt.c
word.obj:word.c word.h
gcc -c word.c
line.obj:line.c line.h
gcc -c line.c

15.4.2 链接期间的错误

常见错误:

  1. Undefined symbol
  2. Unresollved external reference

起因:程序中丢失了函数定义或变量定义,那么链接器将无法解决外部引用。

  1. 拼写错误
  2. 丢失文件
  3. 丢失库

15.4.3 重新构建程序

两种情况:无论哪个文件发生变化,重新编译后都需要重新链接整个程序。

  1. 变化影响单独一个源文件:只对此文件进行重新编译
  2. 变化影响头文件:重新编译所有包含此头文件的源文件

使用makefile重新构建:通过检查每个文件的日期,makefile 可以确定从程序最后一次构建后哪些文件发生了变化。然后根据依赖关系判断如何重新编译。

15.4.4 在程序外定义宏

意义:不需要编辑任何程序文件就对宏的值进行改变。

选项 说明 兼容性
-D 在命令行指定宏的值 大多数UNIX编译器和某些非UNIX编译器
-U 取消指定宏的定义 一些编译器

foo.c

1
#define DEBUG 1

命令行

1
2
$ gcc -DDEBUG=1 foo.c
# gcc -UDEBUG foo.c